angr 使用总结

sky123

angr 是一个多架构二进制分析工具包,能够执行动态符号执行(类似于 Mayhem、KLEE 等)以及各种静态分析。

angr 官方文档

提示

我们可以简单的将 angr 理解为 IdaPython + 符号执行。也就是说:

  • 我们可以将 angr 作为 IdaPython 的替代品。
    • 可以批量对二进制程序进行静态分析,因为不需要将待分析的二进制文件逐个用 Ida 打开。
    • 可以加快分析速度,因为我们不需要像 IdaPython 那样对程序进行完整的分析,比如说我们可以先通过搜索特征定位到关键位置,然后只分析关键位置处的代码。
    • 可以结合 Unicorn 等模拟执行工具实现程序的动态分析,可以应对批量处理分析二进制程序时遇到不同程序之间代码存在差异的问题。
  • 我们还可以使用 angr 的符号执行功能,来弥补静态分析和动态分析的缺陷。
    • 静态分析可能难以处理复杂的控制流(如动态跳转),而动态分析需要依赖真实输入才能覆盖路径。angr 的符号执行功能通过符号化输入的方式探索路径,可以弥补这两者的不足。
    • 由于结合 Unicorn 等模拟执行工具实现程序的动态分析是手动结合的,对于一些特殊的二进制文件可能会出问题,如果可以的话直接用 angr 的符号执行计算一些结果会稳定一些。

angr 安装

直接通过 pip 安装。

1
pip3 install angr

另外还有一个 angr-management 是基于 angr 实现的低配版 IDA。

1
pip3 install angr-management

安装完之后运行 angr-management 命令即可启动,用法和 IDA 基本一样。不过这里我们主要还是在编写逆向辅助工具的时候使用里面的一些 API。

1
angr-management

静态分析

二进制文件加载

加载项目(Project)

加载选项

angr 的第一步是将二进制文件加载到一个项目中。

项目(Project)是你在 angr 中的控制中心。通过它,你可以对加载的可执行文件执行分析和模拟。在 angr 中,几乎所有你要使用的对象都在某种形式上依赖于一个项目。

我们以 /bin/true 为例:

1
2
>>> import angr
>>> proj = angr.Project('/bin/true')

当你使用 angr.Project 加载文件时,可以将选项直接传递给 Project 构造函数,它们会被转发给 CLE。CLE 有如下常用选项:

  • auto_load_libs :控制是否自动解析共享库依赖,默认值为 True
  • except_missing_libs :与 auto_load_libs 相反。如果设置为 True,当无法解析共享库依赖时会抛出异常。
  • force_load_libs :一个字符串列表,强制指定某些库为未解析的共享库依赖。
  • skip_libs :一个字符串列表,防止某些库名被解析为依赖。
  • ld_path :一个字符串或字符串列表,用作共享库的额外搜索路径,优先于默认路径。

你还可以使用 main_optslib_opts 来针对特定的二进制对象设置选项:

  • main_opts :是一个选项名称到值的映射,适用于主二进制。
  • lib_opts :是一个以库名为键、选项字典为值的映射,适用于特定共享库。

常见选项包括:

  • backend :指定使用的后端。

    CLE 当前支持以下静态加载后端:ELF、PE、CGC、Mach-O、ELF 核心转储(core dump)文件。通常情况下,CLE 会自动检测正确的后端,因此除非有非常特殊的需求,否则无需手动指定后端。

    如果需要强制使用某个后端,可以在选项字典中包含一个 backend 键。某些后端无法自动检测架构,必须通过 arch 参数指定。

    以下是支持的后端列表:

    后端名称 描述 是否需要指定架构(arch)?
    elf 基于 PyELFTools 的 ELF 文件静态加载器
    pe 基于 PEFile 的 PE 文件静态加载器
    mach-o Mach-O 文件静态加载器,不支持动态链接或重定位
    cgc Cyber Grand Challenge 二进制文件静态加载器
    backedcgc 支持指定内存和寄存器的 CGC 二进制加载器
    elfcore ELF 核心转储静态加载器
    blob 将文件作为平坦映像加载到内存中
  • base_addr :指定基址。

  • entry_point :指定入口点。

  • arch :指定架构。

1
2
3
4
5
6
>>> angr.Project(
... 'examples/fauxware/fauxware',
... main_opts={'backend': 'blob', 'arch': 'i386'},
... lib_opts={'libc.so.6': {'backend': 'elf'}}
... )
<Project examples/fauxware/fauxware>

基本属性

加载项目后,可以查看一些基本属性,比如 CPU 架构、文件名和入口点的地址。

1
2
3
4
5
6
7
>>> import monkeyhex  # 用于以十六进制格式显示数值结果
>>> proj.arch
<Arch AMD64 (LE)>
>>> proj.entry
0x401670
>>> proj.filename
'/bin/true'
  • arch 是一个 archinfo.Arch 对象的实例,表示程序的编译架构。在本例中,它是小端的 AMD64 架构。该对象包含关于 CPU 的大量信息,常用的属性有 arch.bits(位数)、arch.bytes(字节数)、arch.namearch.memory_endness
  • entry 是二进制文件的入口点地址。
  • filename 是二进制文件的绝对路径。

加载器(The Loader)

将二进制文件加载为虚拟地址空间的表示形式是一个复杂的过程。angr 中有一个模块叫 CLE(CLE Loads Everything)来处理这个问题。CLE 可以通过项目的 .loader 属性访问。

1
2
3
4
5
6
7
>>> proj.loader
<Loaded true, maps [0x400000:0x5004000]>

>>> proj.loader.min_addr
0x400000
>>> proj.loader.max_addr
0x5004000

已加载的对象

CLE 加载器将二进制文件及其所依赖的动态库(这里被称之为二进制对象)加载并映射到一个统一的内存空间中。每个二进制对象由能够处理其文件类型的加载器后端加载(如 cle.Backend 的子类)。例如,cle.ELF 用于加载 ELF 二进制文件。

此外,内存中还有一些对象并不对应任何加载的二进制文件,例如提供线程局部存储(TLS)支持的对象和用于未解析符号的 externs 对象。

CLE 加载器内部对这些对象简单做了一些分类,并存放在 loader 的几个属性中。常见的包括:

  • all_objects:即 CLE 加载的所有对象。

    1
    2
    3
    4
    5
    6
    7
    8
    # 所有加载的对象
    >>> proj.loader.all_objects
    [<ELF Object fauxware, maps [0x400000:0x60105f]>,
    <ELF Object libc-2.23.so, maps [0x1000000:0x13c999f]>,
    <ELF Object ld-2.23.so, maps [0x2000000:0x2227167]>,
    <ELFTLSObject Object cle##tls, maps [0x3000000:0x3015010]>,
    <ExternObject Object cle##externs, maps [0x4000000:0x4008000]>,
    <KernelObject Object cle##kernel, maps [0x5000000:0x5008000]>]
  • main_object :主对象,即 angr.Project 指定加载的二进制文件。

    1
    2
    >>> proj.loader.main_object  # 加载多个二进制文件时,这是主对象
    <ELF Object true, maps [0x400000:0x60721f]>
  • shared_objects:共享对象,即主对象所依赖的动态库。shared_objects 以字典的形式表示,内容为从共享对象名称到对象的映射。

    1
    2
    3
    4
    >>> proj.loader.shared_objects
    { 'fauxware': <ELF Object fauxware, maps [0x400000:0x60105f]>,
    'libc.so.6': <ELF Object libc-2.23.so, maps [0x1000000:0x13c999f]>,
    'ld-linux-x86-64.so.2': <ELF Object ld-2.23.so, maps [0x2000000:0x2227167]> }
  • all_elf_objects:所有从 ELF 文件加载的对象。如果是 Windows 程序,可以使用 all_pe_objects

    1
    2
    3
    4
    >>> proj.loader.all_elf_objects
    [<ELF Object fauxware, maps [0x400000:0x60105f]>,
    <ELF Object libc-2.23.so, maps [0x1000000:0x13c999f]>,
    <ELF Object ld-2.23.so, maps [0x2000000:0x2227167]>]
  • extern_object:用于为未解析的导入和 angr 内部地址。

    1
    2
    >>> proj.loader.extern_object
    <ExternObject Object cle##externs, maps [0x4000000:0x4008000]>
  • kernel_object:用于模拟系统调用的对象。

    1
    2
    >>> proj.loader.kernel_object
    <KernelObject Object cle##kernel, maps [0x5000000:0x5008000]>

除了从 loader 的某个属性中获取加载对象外,我们还可以通过 find_object_containing 方法获取指定地址所在的对象:

1
2
>>> proj.loader.find_object_containing(0x400000)
<ELF Object fauxware, maps [0x400000:0x60105f]>

也可以通过 find_object 方法根据对象名称获取对象:

1
2
>>> proj.loader.find_object('fauxware')
<ELF Object fauxware, maps [0x400000:0x60105f]>

对象基本信息

CLE 加载的二进制对象的属性中包含了一些基本信息:

  • entry:对象的入口点

    1
    2
    >>> proj.loader.main_object.entry
    0x400580
  • min_addrmax_addr:对象的最低地址和最高地址,即对象所在的地址空间范围。

    1
    2
    >>> proj.loader.main_object.min_addr, obj.max_addr
    (0x400000, 0x60105f)
  • linked_base:对象的预链接基址

    预链接基址(Prelinked Base Address) 是在预链接(Prelinking) 过程中为共享对象(Shared Objects,如共享库 *.so 或可执行文件)分配的固定加载地址

    1
    2
    >>> obj.linked_base
    0x400000
  • mapped_base:对象实际被 CLE 映射到内存的基址

    1
    2
    >>> obj.mapped_base
    0x400000
  • execstack:查询该二进制文件是否有可执行栈,即 NX 保护是否未被开启。

    1
    2
    >>> proj.loader.main_object.execstack
    False
  • pic:查询该二进制文件是否是地址无关,即 PIE 保护是否开启。

    1
    2
    >>> proj.loader.main_object.pic
    True

例如用 angr.Project(path, auto_load_libs=False) 载入目标,拿到 proj.loader.main_object(CLE 的 ELF 对象),然后从 ELF 头 / Program Header / Dynamic 等处提取信息得到程序的保护情况。

  • PIE

    1
    "PIE": bool(getattr(obj, "pic", False))
    • CLE 会根据 ELF 类型(ET_DYN 且有解释器)等信息设置 obj.pic
    • 意义:PIE(位置无关可执行)导致装载基址随机化(配合 ASLR),影响泄露与劫持的策略;非 PIE 常出现绝对地址引用

    注意

    极少见的“静态 PIE/特殊链接”可能让该字段不可靠,但一般足够实用。

  • RELRO(Partial / Full)

    1
    2
    3
    has_relro = _has_relro_segment(obj)     # PT_GNU_RELRO 存在
    bind_now = _bind_now_enabled(obj) # DT_BIND_NOW / DF_BIND_NOW / DF_1_NOW
    info["RELRO"] = "NONE" if not has_relro else ("FULL" if bind_now else "PARTIAL")
    • Partial RELRO:存在 PT_GNU_RELRO,但启用 BIND_NOW;启动后仅部分区段只读,.got.plt 仍可写 → 仍可 GOT 覆写
    • Full RELRO:存在 PT_GNU_RELRO 且启用 BIND_NOW;所有符号启动时解析,GOT 页重保护为只读 → 阻断 GOT 覆写

    提示

    CTF 中 RELRO=PARTIAL 往往意味着可尝试 free@GOT → system 等覆盖链;RELRO=FULL 则考虑 vtable/hook/IO/heap 等路线。

  • NX(不可执行栈)

    1
    2
    3
    nx_attr   = getattr(obj, "nx", None)            # 部分 CLE 直接给出
    execstack = getattr(obj, "execstack", None) # PT_GNU_STACK 可执行性
    info["NX"] = (bool(nx_attr) if nx_attr is not None else False) or (execstack is not None and not bool(execstack))
    • 基于 PT_GNU_STACKX 标志;这里做了 双路径兜底obj.nx 为真或 not obj.execstack 即认为 NX 开启。
    • 意义NX=True 需要 ROP/ret2libc/ret2dlresolve 等;NX=False 可直接在栈上执行 shellcode。

    注意

    个别工具链会让 obj.nx 过于保守;加上 execstack 判断能提升兼容性。

  • Stack Canary

    1
    2
    names = _all_symbol_names(obj)
    info["Canary"] = ("__stack_chk_fail" in names) or ("__stack_chk_guard" in names)
    • 通过是否出现 __stack_chk_fail / __stack_chk_guard 推断启用 -fstack-protector*
    • 意义:有 Canary 时,栈溢出需泄露 canary绕过写入(如 off-by-one 不覆盖 canary)。

    注意

    静态链接/强 LTO 或严重 strip 可能导致符号不可见而漏报。更“真”的做法是检查重定位是否引用 __stack_chk_fail

  • FORTIFY

    1
    info["FORTIFY"] = any(n.endswith("_chk") for n in names)  # 如 __memcpy_chk / __sprintf_chk
    • -D_FORTIFY_SOURCE 会把部分不安全 API 重写为 *_chk 版本,添加边界检查。
    • 意义:存在 _chk 族符号提示启用 FORTIFY。

    注意

    仅“有符号”不等于“实际调用”,严格应检查调用/重定位点;本法偏宽,可能小幅误报,但对 CTF 足够。

  • RWX(可写且可执行的装载段)

    1
    info["RWX"] = any(seg.is_executable and seg.is_writable for seg in getattr(obj, "segments", []) or [])
    • PT_LOAD 段,出现 W+X 违反 W^X 原则。
    • 意义:存在 RWX 时可写入 shellcode 并直接跳转,利用链更简单。
  • RPATH / RUNPATH

    1
    2
    info["RPATH"]   = bool(getattr(obj, "rpath", None))     # DT_RPATH
    info["RUNPATH"] = bool(getattr(obj, "runpath", None)) # DT_RUNPATH
    • .dynamic 中读取库搜索路径设置。
    • 意义:不安全搜索路径可能带来动态库劫持(SUID 情况下动态链接器会有额外限制)。
  • Stripped

    1
    2
    has_symtab = _has_section(obj, ".symtab")
    info["Stripped"] = (not has_symtab)
    • 缺少 .symtab 视为 stripped(快速近似)。
    • 意义:影响逆向效率,但不直接改变利用面。

关键辅助函数(原理与作用)

  • _has_relro_segment(obj)
    依据 obj.relro(CLE 对 RELRO 的抽象)判断是否存在 PT_GNU_RELRO,并据此参与 PARTIAL/FULL 的判定。

  • _iter_dynamic_entries(obj)_bind_now_enabled(obj)
    兼容不同 CLE 版本的 .dynamic 表示,尽量抽取 (tag, val);在其中识别:

    • DT_BIND_NOW,或
    • DT_FLAGSDF_BIND_NOW (0x8),或
    • DT_FLAGS_1DF_1_NOW (0x1)
      以此判断是否 BIND_NOW(Full RELRO 的关键)。
    1
    2
    3
    4
    5
    6
    7
    def _bind_now_enabled(obj):
    DF_BIND_NOW, DF_1_NOW = 0x8, 0x1
    for tag, val in _iter_dynamic_entries(obj):
    if str(tag) == "DT_BIND_NOW": return True
    if str(tag) == "DT_FLAGS" and int(val) & DF_BIND_NOW: return True
    if str(tag) == "DT_FLAGS_1" and int(val) & DF_1_NOW: return True
    return False
  • _nx_enabled(obj)
    双路径:obj.nxnot obj.execstack,提升不同工具链/cle 版本下的兼容性。

  • _all_symbol_names(obj)
    聚合 symbols/imports/exports/symbols_by_name 的名字集合,提高 canary/fortify 判定的召回率。

  • _has_rwx_load(obj)
    遍历 segments,查同时 is_executable && is_writable 的装载段。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
#!/usr/bin/env python3
# -*- coding: utf-8 -*-

import sys
import json
import angr

def _has_section(obj, name: str) -> bool:
for sec in getattr(obj, "sections", []) or []:
n = getattr(sec, "name", None) or getattr(sec, "name_string", None)
if n == name:
return True
return False

def _all_symbol_names(obj):
names = set()
for s in getattr(obj, "symbols", []) or []:
n = getattr(s, "name", None)
if n:
names.add(n)
for s in getattr(obj, "imports", []) or []:
n = s if isinstance(s, str) else getattr(s, "name", None)
if n:
names.add(n)
for s in getattr(obj, "exports", []) or []:
n = getattr(s, "name", None)
if n:
names.add(n)
for n in (getattr(obj, "symbols_by_name", {}) or {}).keys():
names.add(n)
return names

def _has_rwx_load(obj) -> bool:
for seg in getattr(obj, "segments", []) or []:
if getattr(seg, "is_executable", False) and getattr(seg, "is_writable", False):
return True
return False

def _has_relro_segment(obj) -> bool:
"""
cle.obj.relro!=NONE 时一定存在 RELRO;某些 cle 版本不暴露非 PT_LOAD 段,
这里以 obj.relro!=NONE 作为“有 RELRO 段”的可信来源。
"""
relro_v = getattr(obj, "relro", None)
name = getattr(relro_v, "name", "") if relro_v is not None else ""
return name.upper().endswith("PARTIAL") or name.upper().endswith("FULL")

def _iter_dynamic_entries(obj):
"""
在不同 cle 版本里,dynamic 可能是 dict / list / 自定义对象。
尽量抽取成 (tag_name:str, value:int|str) 的生成器。
"""
# 尝试几个常见属性名
for attr in ("dynamic", "dynamic_entries", "dynamic_tags", "_dynamic"):
dyn = getattr(obj, attr, None)
if dyn is None:
continue

# dict-like
if isinstance(dyn, dict):
for k, v in dyn.items():
yield str(k), v
return

# iterable of entries
try:
for ent in dyn:
# 各种可能字段名
tag = getattr(ent, "tag", None)
val = getattr(ent, "val", None)

if tag is None and hasattr(ent, "d_tag"):
tag = ent.d_tag
if val is None and hasattr(ent, "d_val"):
val = ent.d_val

# 某些实现把原始 entry 挂在 entry/dict 里
if tag is None and hasattr(ent, "entry"):
tag = ent.entry.get("d_tag", None)
if val is None and hasattr(ent, "entry"):
val = ent.entry.get("d_val", None)

if tag is None:
continue
yield str(tag), val
return
except TypeError:
pass

# 最后再从对象直接取可能解析出的字段
for k in ("rpath", "runpath"):
if hasattr(obj, k):
yield "DT_RPATH" if k == "rpath" else "DT_RUNPATH", getattr(obj, k)

def _bind_now_enabled(obj) -> bool:
"""
Full RELRO 的关键:BIND_NOW。
识别:DT_BIND_NOW 存在;或 DT_FLAGS 含 DF_BIND_NOW(0x8);
或 DT_FLAGS_1 含 DF_1_NOW(0x1)。
"""
DF_BIND_NOW = 0x8
DF_1_NOW = 0x1

for tag, val in _iter_dynamic_entries(obj):
t = tag if isinstance(tag, str) else str(tag)
if t == "DT_BIND_NOW":
return True
if t == "DT_FLAGS":
try:
if int(val) & DF_BIND_NOW:
return True
except Exception:
pass
if t == "DT_FLAGS_1":
try:
if int(val) & DF_1_NOW:
return True
except Exception:
pass
return False

def _nx_enabled(obj) -> bool:
"""
cle 的 obj.nx 在部分版本/文件上可能保守为 False。
双路径:NX = obj.nx or (not obj.execstack)
"""
nx_attr = getattr(obj, "nx", None)
execstack = getattr(obj, "execstack", None)
nx_via_nx = bool(nx_attr) if nx_attr is not None else False
nx_via_execstack = (execstack is not None) and (not bool(execstack))
return nx_via_nx or nx_via_execstack

def checksec_angr(path: str):
proj = angr.Project(path, auto_load_libs=False)
obj = proj.loader.main_object

# 基础
info = {
"File" : getattr(obj, "binary_basename", None) or path,
"Arch" : str(obj.arch),
"Bits" : obj.arch.bits,
"PIE" : bool(getattr(obj, "pic", False)),
}

# RELRO
has_relro = _has_relro_segment(obj)
bind_now = _bind_now_enabled(obj)
if not has_relro:
relro = "NONE"
else:
relro = "FULL" if bind_now else "PARTIAL"
info["RELRO"] = relro

# NX
info["NX"] = _nx_enabled(obj)

# 其余
names = _all_symbol_names(obj)
info["Canary"] = ("__stack_chk_fail" in names) or ("__stack_chk_guard" in names)
info["FORTIFY"] = any(n.endswith("_chk") for n in names)
info["RWX"] = _has_rwx_load(obj)
info["RPATH"] = bool(getattr(obj, "rpath", None))
info["RUNPATH"] = bool(getattr(obj, "runpath", None))
# stripped:无 .symtab 视为 stripped(若 cle 提供 sections)
has_symtab = _has_section(obj, ".symtab")
info["Stripped"] = (not has_symtab)

return info

def main():
if len(sys.argv) < 2:
print(f"Usage: {sys.argv[0]} <elf> [--json]")
sys.exit(1)

path = sys.argv[1]
info = checksec_angr(path)

if len(sys.argv) >= 3 and sys.argv[2] == "--json":
print(json.dumps(info, indent=2, ensure_ascii=False))
return

keys = ["File","Arch","Bits","PIE","RELRO","NX","Canary","FORTIFY","RWX","RPATH","RUNPATH","Stripped"]
pad = max(len(k) for k in keys)
for k in keys:
print(f"{k.rjust(pad)} : {info[k]}")

if __name__ == "__main__":
main()

段(Segment)和节(Section)

CLE 加载的二进制对象还会解析获取对应二进制文件的段和节信息。这些信息分别存放在二进制对象的 segmentssections 属性中。

1
2
3
4
5
6
7
8
9
# 获取 ELF 的段(segments)和节(sections)
>>> obj.segments
<Regions: [<ELFSegment memsize=0xa74, filesize=0xa74, vaddr=0x400000, flags=0x5, offset=0x0>,
<ELFSegment memsize=0x238, filesize=0x228, vaddr=0x600e28, flags=0x6, offset=0xe28>]>
>>> obj.sections
<Regions: [<Unnamed | offset 0x0, vaddr 0x0, size 0x0>,
<.interp | offset 0x238, vaddr 0x400238, size 0x1c>,
<.note.ABI-tag | offset 0x254, vaddr 0x400254, size 0x20>,
...(省略部分输出)]>

我们可以通过二进制对象的 find_segment_containingfind_section_containing 获取指定地址所位于的段和节。

1
2
3
4
5
>>> obj.find_segment_containing(obj.entry)
<ELFSegment memsize=0xa74, filesize=0xa74, vaddr=0x400000, flags=0x5, offset=0x0>

>>> obj.find_section_containing(obj.entry)
<.text | offset 0x580, vaddr 0x400580, size 0x338>

angr 没有直接通过节的名称搜索节的 api,因此需要手动遍历节来获取。

1
text_section = [section for section in self.project.loader.main_object.sections if section.name == '.text'][0]

内存数据

angr 的内存操作接口分为 静态内存接口(加载时内存布局)和 动态内存接口(符号执行时的内存状态)。其中 project.loader.memory 属于静态内存接口,是二进制文件加载到内存后的初始布局(如代码段、数据段、符号表等)。

  • 搜索内存 :首先我们可以通过其中的 find 方法搜索我们想要的数据,返回结果是一个迭代器:

    1
    def find(self, data: bytes, search_min: int = None, search_max: int = None) -> Iterator[int]:
    • 参数:

      • data :要搜索的字节序列(bytes 类型)。这是你希望在内存中查找的数据模式。

      • search_min :可选参数,指定搜索的最小地址。只有在该地址之后或等于该地址的内存区域才会被搜索。如果不提供,默认从内存的起始位置开始。

      • search_max :可选参数,指定搜索的最大地址。只有在该地址之前或等于该地址的内存区域才会被搜索。如果不提供,默认搜索到内存的末尾。

    • 返回值:该方法返回一个迭代器,迭代器会逐一返回包含字节序列 data 的所有内存地址。

  • 读取内存 :通过 load 方法可以读取指定内存地址的数据,返回值是一个字节序列。此方法会读取指定地址开始的最多 n 个字节,直到达到指定字节数或者遇到未分配的内存区域:

    1
    def load(self, addr: int, n: int) -> bytes:
    • 参数:

      • addr :指定读取的起始内存地址。

      • n :要读取的字节数。

    • 返回值:返回一个字节对象(bytes 类型),包含读取到的数据。如果在读取过程中遇到未分配的内存区域,方法会停止,并返回已读取的字节数据。

  • 写入内存 :通过 store 方法可以将字节数据 data 写入到指定的内存地址 addr。如果写入操作超过了当前内存区域的范围,方法会尝试更新现有的内存区域,并抛出 KeyError 异常。

    1
    def store(self, addr, data):
    • 参数:
      • addr :要写入的目标内存地址(int 类型)。
      • data :要写入内存的字节数据(bytes 类型)。
    • 返回值:此方法没有返回值。如果成功写入数据,它会直接修改内存中的数据;如果出现问题,它会抛出 KeyError 异常。

符号信息

符号地址

从 CLE 中获取符号最简单的方法是使用 loader.find_symbol,它接受一个名称或地址,并返回一个 Symbol 对象。

1
2
3
>>> strcmp = proj.loader.find_symbol('strcmp')
>>> strcmp
<Symbol "strcmp" in libc.so.6 at 0x1089cd0>

符号最有用的属性包括其名称(name)、所属对象(owner)以及地址(address)。但符号的“地址”可能是模糊的,Symbol 对象提供了三种方式报告其地址:

  • .rebased_addr :符号在全局地址空间中的地址,这也是打印输出中显示的地址。
  • .linked_addr :符号相对于二进制文件预链接基址(prelinked base)的地址。例如,这是 readelf 等工具中报告的地址。
  • .relative_addr :符号相对于其所属对象基址(object base)的地址。在文献(尤其是 Windows 文献)中,这被称为 RVA(Relative Virtual Address)。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
>>> strcmp.name
'strcmp'

>>> strcmp.owner
<ELF Object libc-2.23.so, maps [0x1000000:0x13c999f]>

>>> strcmp.rebased_addr
0x1089cd0

>>> strcmp.linked_addr
0x89cd0

>>> strcmp.relative_addr
0x89cd0

除了调试信息外,符号还支持动态链接(dynamic linking)的概念。例如,libc 提供了 strcmp 作为导出符号,而主二进制程序依赖它。如果我们让 CLE 直接从主对象中返回 strcmp 符号,它会告诉我们这是一个导入符号(import symbol)。导入符号没有有意义的地址,但它会提供一个引用,指向用于解析它的符号(通过 .resolvedby 属性)。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
>>> strcmp.is_export
True

>>> strcmp.is_import
False

# 对 Loader 来说,使用的是 find_symbol 方法,因为它需要进行搜索以找到符号。
# 对单个对象来说,使用的是 get_symbol 方法,因为给定名称的符号在对象内部只能有一个。
>>> main_strcmp = proj.loader.main_object.get_symbol('strcmp')
>>> main_strcmp
<Symbol "strcmp" in fauxware (import)>

>>> main_strcmp.is_export
False

>>> main_strcmp.is_import
True

>>> main_strcmp.resolvedby
<Symbol "strcmp" in libc.so.6 at 0x1089cd0>

符号的 PLT 地址

对于某些符号,你可以通过加载的对象获取它们在 PLT表(Procedure Linkage Table)中的地址:

1
2
3
4
5
6
7
8
9
>>> obj = proj.loader.main_object

>>> addr = obj.plt['strcmp']
>>> addr
0x400550

# 根据 PLT 地址反向查找符号
>>> obj.reverse_plt[addr]
'strcmp'

符号的 GOT 地址

导入和导出符号之间的链接方式是通过重定位(relocations)管理的。重定位记录了以下信息:

当你将 [import] 符号与某个导出符号匹配时,请将导出符号的地址写入 [location](即符号对应的 GOT 表地址),格式为 [format]。

我们可以获取重定位的相关信息:

  • 通过 obj.relocs 获取某个对象的所有重定位列表(以 Relocation 实例表示)。
  • 通过 obj.imports 获取从符号名称到重定位的映射。注意,导出符号没有对应的列表。

例如我们可以通过 imports 获取 exit 函数的 GOT 表地址:

1
2
3
4
5
>>> proj.loader.main_object.imports['exit'].rebased_addr
0x406f88

>>> proj.loader.main_object.find_section_containing(0x406f88)
<.got | offset 0x5e88, vaddr 0x406e88, size 0x178>

二进制代码分析

基本块(Blocks)

在 angr 中,基本块(Basic Block) 是指一段连续的、没有跳跃(即没有分支指令)的指令序列,通常在程序执行过程中,这些指令是按顺序执行的。

具体来说,基本块有以下几个特点:

  • 没有跳跃或分支 :基本块内的指令是顺序执行的,不包含跳转(如 jmpcallret 等指令)或条件分支指令(如 ifbranch 等)。当程序执行到一个基本块时,它会按照顺序执行该块内的所有指令,直到遇到跳转指令或基本块结束。

    注意

    与 IDA 的 CFG 的代码块不同的是,angr 的代码块把函数调用(call)也作为代码块结束的标志。

  • 入口和出口 :每个基本块有一个入口(起始地址)和出口(结束地址)。出口通常是一个跳转或返回的指令,也可能是下一个基本块的开始。

  • 分析单元 :在程序分析过程中,基本块是分析的最小单元。angr 就是通过将程序拆分成多个基本块来进行符号执行(symbolic execution)和路径探索。

基本块的获取

在 angr 中,通过 project.factory.block() 方法可以提取某个地址的基本块。

angr 中有很多类,其中大多数需要实例化一个项目(project)。为了避免你到处传递项目实例,我们提供了 project.factory,它包含了几个方便的构造器,用于创建你经常需要使用的常见对象。

这些基本块的内容被封装在 Block 对象中,你可以通过该对象访问基本块的反汇编信息、指令数量、指令地址等数据。

注意

project.factory.block 提取的代码块并不是控制流程图中的代码块。控制流图会考虑到代码的所有跳转关系,包括跳转到基本块的中间位置,因此在控制流图中,基本块的划分会比单纯用 project.factory.block 提取的基本块更加复杂。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
>>> block = proj.factory.block(proj.entry)  # 从程序入口点提取一个代码块
<Block for 0x401670, 42 bytes>

>>> block.pp() # 将反汇编结果打印到标准输出
0x401670: xor ebp, ebp
0x401672: mov r9, rdx
0x401675: pop rsi
0x401676: mov rdx, rsp
0x401679: and rsp, 0xfffffffffffffff0
0x40167d: push rax
0x40167e: push rsp
0x40167f: lea r8, [rip + 0x2e2a]
0x401686: lea rcx, [rip + 0x2db3]
0x40168d: lea rdi, [rip - 0xd4]
0x401694: call qword ptr [rip + 0x205866]

>>> block.instructions # 这个代码块有多少条指令?
0xb
>>> block.instruction_addrs # 指令的地址是什么?
[0x401670, 0x401672, 0x401675, 0x401676, 0x401679, 0x40167d, 0x40167e, 0x40167f, 0x401686, 0x40168d, 0x401694]

基本块的使用

每个 Block 对象包含一个反汇编的指令列表。你可以通过 block.capstone 获取该基本块的反汇编指令。capstone 是一个流行的反汇编库,angr 使用它来生成反汇编指令。

1
2
for insn in block.capstone.insns:
print(f"{insn.address:#x}: {insn.mnemonic} {insn.op_str}")

在我们开发逆向辅助脚本的时候,基本块的其中一个作用就是可以提取某个地址处的 gadget

1
2
3
def get_gadget(addr):
gadget_str = "; ".join([f"{insn.mnemonic} {insn.op_str}" for insn in proj.factory.block(addr).disassembly.insns]).strip()
return gadget_str + ";" if gadget_str != "" else None

我们还可以借助基本块来进行一些复杂的程序分析,不过这里先不做介绍。

控制流程图(CFG)

控制流图(Control Flow Graph,简称 CFG) 是一种图形化的表示方法,用于描述程序中各个基本块之间的控制流关系。它将程序中的 基本块 作为节点,表示控制流的 跳转指令(如 jmpcallret 等)作为边。

利用 angr 提取 CFG

angr 中,有两种类型的控制流图(CFG)可以生成:静态 CFG(CFGFast)和动态 CFG(CFGEmulated)。

提示

如果你不确定使用哪个 CFG,或者遇到 CFGEmulated 的问题,建议首先尝试使用 CFGFast

  • CFGFast 使用静态分析来生成控制流图。它显著更快,但理论上受限于一些控制流转换只能在执行时解析的事实。这是其他流行的逆向工程工具执行的同类控制流图分析,其结果与它们的输出可比。
  • CFGEmulated 使用符号执行来捕捉控制流图。尽管它理论上更精确,但它显著更慢。由于模拟精度的问题(如系统调用、缺少硬件特性等),通常它也不那么完整。

可以通过以下代码构建控制流图:

1
2
3
4
5
6
7
8
9
>>> import angr
# 加载项目
>>> p = angr.Project('/bin/true', load_options={'auto_load_libs': False})

# 生成静态控制流图(CFG)
>>> cfg = p.analyses.CFGFast()

# 生成动态控制流图(CFG)
>>> cfg = p.analyses.CFGEmulated(keep_state=True)

提示

控制流图分析不会区分来自不同二进制对象的代码。这意味着默认情况下,它会尝试分析通过加载的共享库进行的控制流。这几乎从来不是预期的行为,因为这会使分析时间变得极长。要加载没有共享库的二进制文件,可以在 Project 构造函数中添加以下关键字参数:load_options={'auto_load_libs': False}

控制流图的核心是一个 NetworkX 有向图(di-graph)。这意味着所有常规的 NetworkX API 都可用:

1
2
>>> print("This is the graph:", cfg.graph)
>>> print("It has %d nodes and %d edges" % (len(cfg.graph.nodes()), len(cfg.graph.edges())))

CFGNode 类的实例代表了控制流图中的每个基本块。你可以通过 cfg.get_any_node() 获取给定地址的任何一个节点,或者通过 cfg.get_all_nodes() 获取所有上下文下的节点。

由于程序可能在多个上下文中执行,相同的基本块在不同的上下文下可能会有不同的表现。因此,同一个基本块可能会在图中有多个节点(表示不同的执行上下文)。

1
2
3
4
5
# 获取给定位置(入口点)对应的任意一个节点
>>> entry_node = cfg.get_any_node(p.entry)

# 获取给定位置(入口点)所有上下文的节点
>>> print("There were %d contexts for the entry block" % len(cfg.get_all_nodes(p.entry)))

CFGNode 还具有 predecessorssuccessors 属性,分别表示当前节点的前驱和后继节点。

1
2
3
4
5
6
7
8
# 获取入口节点的前驱
>>> print("Predecessors of the entry point:", entry_node.predecessors)

# 获取入口节点的后继
>>> print("Successors of the entry point:", entry_node.successors)

# 获取入口节点的后继节点及跳转类型
>>> print("Successors (and type of jump) of the entry point:", [jumpkind + " to " + str(node.addr) for node, jumpkind in cfg.get_successors_and_jumpkind(entry_node)])

在 IDA 中,控制流图是以函数为单位的,也就是说,IDA 将每个函数作为一个单独的基本块进行分析,并将函数的入口和出口视为控制流的边界。而在 angr 中,控制流图是基于 基本块 的,call 指令通常被视为 跳转(或控制流转移)的一个标志,而不是函数的边界。控制流图会继续分析 call 指令后的指令,但不会自动将其视为函数的边界。

angrmanagementto_supergraph 函数用于将 angr 的控制流图转换成一个函数级别的控制流图。to_supergraph 会把 angr 的单个函数的 CFG 提取出来,并将其转化为 IDA 样式的图。

1
2
3
4
5
6
7
8
9
10
11
def get_cfg():
# 使用 CFGFast 分析生成控制流图
cfg = proj.analyses.CFGFast(normalize=True, force_complete_scan=False)

# 获取某个函数的控制流图
function_cfg = cfg.functions.get(start).transition_graph

# 使用 to_supergraph 将该函数的控制流图转化为类似 IDA 的图
super_cfg = to_supergraph(function_cfg)

return super_cfg

手动提取 CFG

如果一个二进制程序比较大,那么使用 angr 内置的 CFG 生成方法会非常的慢。对于这种情况通常我们都会自己实现一个近似的提取 CFG 的方法,针对一个特定函数开始提取 CFG。

下面这段代码是针对 x86 架构遍历一个特定函数的 CFG 的代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
def bfs_cfg(self, block_handler: Callable[[List[CsInsn]], Any]):
"""
广度优先遍历函数内控制流图(CFG)的核心方法(x86架构专用)
:param block_handler: 基本块处理回调函数,接收指令列表,返回非空值时终止遍历
:return: block_handler的返回值或None
"""

# 缓存分支路径已分析的基本块(避免重复处理)
if self.blocks != None:
for block in self.blocks:
# 获取基本块的反汇编指令列表,调用处理函数
result = block_handler(list(self.project.factory.block(block).capstone.insns))
if result: return result # 根据回调结果提前终止
return None

# 内部函数:判断地址是否在当前函数范围内 --------------------------------
def in_func_range(addr):
# 首次运行初始化.text段地址范围
if self.text_range == None:
# 从二进制文件获取.text段信息(ELF/PE格式)
text_section = [section for section in self.project.loader.main_object.sections
if section.name == '.text'][0]
self.text_range = range(text_section.vaddr, text_section.vaddr + text_section.filesize)

# 地址超出.text段范围直接过滤
if addr not in self.text_range:
return False

# 排除其他函数入口(通过识别函数序言)
block = self.project.factory.block(addr)
insns: List[CsInsn] = list(block.capstone.insns)
return not (
len(insns) >= 2 and
insns[0].mnemonic == 'push' and insns[0].op_str == 'rbp' # 函数开头push rbp
and insns[1].mnemonic == 'mov' and insns[1].op_str == 'rbp, rsp' # mov rbp, rsp
)

# BFS初始化 --------------------------------------------------------
block_queue = Queue() # 使用队列实现广度优先搜索
self.blocks: Set[int] = set() # 记录已访问的基本块地址

# 将起始地址加入队列(函数入口地址)
block_queue.put(self.addr)
self.blocks.add(self.addr)

# BFS主循环 --------------------------------------------------------
while not block_queue.empty():
# 获取当前基本块(通过angr解析)
block = self.project.factory.block(block_queue.get())
insns: List[CsInsn] = block.capstone.insns # Capstone反汇编结果

# 处理当前基本块指令(回调机制)
result = block_handler(insns)
if result: return result # 回调返回非空值时终止遍历

# 获取最后一条指令(分支指令分析)
last_insn = insns[-1]

# 遇到返回指令(ret)停止当前路径探索
if last_insn.mnemonic.startswith('ret'):
continue

# 处理条件跳转指令(jz/jne等)------------------------------------
if (last_insn.mnemonic.startswith('j') and # 跳转指令族
last_insn.operands[0].type == X86_OP_IMM): # 立即数操作数
next_block = last_insn.operands[0].imm # 跳转目标地址

# 过滤跨函数跳转(通过地址范围和函数序言判断)
if next_block not in self.blocks and in_func_range(next_block):
self.blocks.add(next_block)
block_queue.put(next_block)

# 处理顺序执行流(非jmp指令)-------------------------------------
if last_insn.mnemonic != 'jmp': # 排除无条件跳转
next_block = last_insn.address + last_insn.size # 计算下一条指令地址
if next_block not in self.blocks:
self.blocks.add(next_block)
block_queue.put(next_block)

return None

下面这段代码是针对 arm 架构遍历一个特定函数的 CFG 的代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
def bfs_cfg(self, block_handler: Callable[[List[CsInsn]], Any]):
"""
广度优先遍历控制流图(CFG)的核心方法
:param block_handler: 基本块处理回调函数,接收指令列表,返回非空值时终止遍历
:return: block_handler的返回值或None
"""

# 如果已缓存基本块地址,直接遍历处理(缓存优化)
if self.blocks != None:
for block in self.blocks:
# 获取基本块的反汇编指令列表,调用处理函数
result = block_handler(list(self.project.factory.block(block).capstone.insns))
if result: return result # 根据回调结果提前终止
return None

# BFS初始化 ----------------------------------------------------------
block_queue = Queue() # 使用队列实现广度优先搜索
self.blocks: Set[int] = set() # 记录已访问的基本块地址(Thumb模式地址需|1)

# 将起始地址加入队列(ARM Thumb模式处理:地址末位置1)
block_queue.put(self.addr | 1) # 例如 0x1000 -> 0x1001
self.blocks.add(self.addr | 1)

# BFS主循环 ----------------------------------------------------------
while not block_queue.empty():
# 获取当前基本块
block_addr = block_queue.get()
block = self.project.factory.block(block_addr) # 获取基本块对象
cs_block = block.capstone # Capstone反汇编对象

# 处理当前基本块指令
result = block_handler(cs_block.insns) # 传递指令列表给回调函数
if result: return result # 回调返回非空值时终止遍历

# 获取最后一条指令
last_insn = cs_block.insns[-1]

# 分支指令处理逻辑 -----------------------------------------------
# 情况1:跳过无条件跳转(b.w)
if last_insn.mnemonic == 'b.w':
continue # 不处理后续逻辑

# 情况2:处理非函数调用的直接跳转(非bl指令的立即数操作)
if (not last_insn.mnemonic.startswith('bl')) and \
(last_insn.operands[0].type == ARM_OP_IMM):
next_block = last_insn.operands[0].imm # 获取跳转目标地址
self._add_next_block(next_block, block_queue) # 添加到队列

# 情况3:顺序执行的下一个块(非分支/非返回指令)
if (last_insn.mnemonic != 'b') and \
(not last_insn.mnemonic.startswith('pop')) and \
(last_insn.mnemonic != 'bx'):
next_block = last_insn.address + last_insn.size # 计算下一条指令地址
self._add_next_block(next_block, block_queue)

# 情况4:处理条件分支指令(cbz/cbnz等)
for insn in cs_block.insns:
if insn.mnemonic.startswith('cb') and \
insn.operands[1].type == ARM_OP_IMM:
next_block = insn.operands[1].imm # 条件跳转目标地址
self._add_next_block(next_block, block_queue)

return None

下面这段代码是针对 aarch64 架构遍历一个特定函数的 CFG 的代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
def bfs_cfg(self, block_handler: Callable[[List[CsInsn]], Any]):
"""
ARM64架构函数内控制流图广度优先遍历方法
:param block_handler: 基本块处理回调函数,返回非空值时终止遍历
:return: block_handler的返回值或None
"""

# 缓存分支路径已分析的基本块(避免重复处理)
if self.blocks != None:
for block in self.blocks:
# 获取基本块的反汇编指令列表,调用处理函数
result = block_handler(list(self.project.factory.block(block).capstone.insns))
if result: return result # 根据回调结果提前终止
return None

# 获取.text段地址范围 -----------------------------------------------
text_section = [section for section in self.project.loader.main_object.sections
if section.name == '.text'][0]
text_section_range = range(text_section.vaddr,
text_section.vaddr + text_section.filesize) # 注意修正结束地址

# 内部函数:判断地址是否在当前函数范围内 ------------------------------
def in_func_range(addr):
# 地址超出.text段范围直接过滤
if addr not in text_section_range:
return False

# 检查是否为其他函数入口(通过识别函数序言指令)
block = self.project.factory.block(addr)
for insn in block.capstone.insns:
# ARM64函数序言特征:stp x29, x30(保存帧指针和链接寄存器)
if insn.mnemonic == 'stp' and 'x29, x30' in insn.op_str:
return False
return True

# BFS初始化 --------------------------------------------------------
block_queue = Queue() # 使用队列实现广度优先搜索
self.blocks: Set[int] = set() # 记录已访问的基本块地址

# 将起始地址加入队列(函数入口地址)
block_queue.put(self.addr)
self.blocks.add(self.addr)

# BFS主循环 --------------------------------------------------------
while not block_queue.empty():
# 获取当前基本块(通过angr解析)
block = self.project.factory.block(block_queue.get())
cs_block = block.capstone # Capstone反汇编对象

# 处理当前基本块指令(回调机制)
result = block_handler(cs_block.insns)
if result: return result # 回调返回非空值时终止遍历

# 获取最后一条指令(分支指令分析)
last_insn = cs_block.insns[-1]

# 遇到返回指令(ret)停止当前路径探索
if last_insn.mnemonic == 'ret':
continue

# 处理非函数调用的直接跳转(b指令)------------------------------
if (not last_insn.mnemonic.startswith('bl')) and \ # 排除bl指令(函数调用)
(last_insn.operands[0].type == ARM64_OP_IMM): # 立即数跳转目标
next_block = last_insn.operands[0].imm
if next_block not in self.blocks and in_func_range(next_block):
self.blocks.add(next_block)
block_queue.put(next_block)

# 处理条件分支指令(cbz/cbnz等)-------------------------------
if last_insn.mnemonic.startswith('cb') and \ # 条件分支指令族
last_insn.operands[1].type == ARM64_OP_IMM:
next_block = last_insn.operands[1].imm
if next_block not in self.blocks and in_func_range(next_block):
self.blocks.add(next_block)
block_queue.put(next_block)

# 处理顺序执行流(非无条件跳转)-------------------------------
if last_insn.mnemonic != 'b': # 排除无条件跳转指令
next_block = last_insn.address + last_insn.size # 计算下一条指令地址
if next_block not in self.blocks:
self.blocks.add(next_block)
block_queue.put(next_block)

return None

上述示例代码只是近似遍历指定函数 CFG,然后针对其中的每一个代码块调用回调函数。由于没有对整个二进制文件进行完整分析,并且例如判断函数边界等都过于简略,因此并不适用于所有情况(例如有 switch 跳转表的函数会丢失大量分支代码,需要额外编写针对跳转表的处理逻辑)。

函数

angr 的函数分析

控制流图(CFG)的结果会生成一个名为 函数管理器(Function Manager) 的对象,可以通过 cfg.kb.functions 访问。该对象最常见的使用方式是像字典一样访问,它将地址映射到 Function 对象,Function 对象可以提供关于函数的各种属性。

1
>>> entry_func = cfg.kb.functions[p.entry]

Function 对象具有多个重要属性:

  • entry_func.block_addrs :一个集合,包含函数中所有基本块的起始地址。
  • entry_func.blocks :包含该函数所有基本块的集合,可以使用 Capstone 进行反汇编和探索。
  • entry_func.string_references() :返回一个列表,包含函数中所有被引用的常量字符串。列表中的每个项是一个元组 (addr, string),其中:
    • addr 是字符串所在的二进制数据段中的地址。
    • string 是一个 Python 字符串,包含字符串的实际内容。
  • entry_func.returning :一个布尔值,表示函数是否能返回。False 表示该函数的所有路径都不会返回。
  • entry_func.callable :一个 angrCallable 对象,表示该函数。你可以像调用 Python 函数一样调用它,并传入 Python 参数,返回的结果可能是实际结果(可能是符号化的),就像你运行了该函数一样。
  • entry_func.transition_graph :一个 NetworkX 的有向图(DiGraph),描述函数内部的控制流。它类似于 IDA 所显示的每个函数级别的控制流图。
  • entry_func.name :函数的名称。
  • entry_func.has_unresolved_callsentry_func.has_unresolved_jumps :这些属性与检测 CFG 的不精确性有关。有时,分析无法检测出间接调用或跳转的目标地址。如果发生这种情况,该函数的相关属性将被设置为 True
  • entry_func.get_call_sites() :返回一个列表,包含所有以调用指令结尾的基本块地址。
  • entry_func.get_call_target(callsite_addr) :给定一个调用地址 callsite_addr,返回该调用指令的目标地址。
  • entry_func.get_call_return(callsite_addr) :给定一个调用地址 callsite_addr,返回该调用指令应该返回的地址。

手动提取函数

实际上前面的遍历 CFG 本质上就是在遍历函数的代码,这里主要介绍一下如何确定函数的起始地址。

为了避免代码速度过慢,我们需要通过搜索特征而不是 angr 的函数分析来确定函数起始地址,这种方法虽然不严谨,但是在多数情况下是准确的。

下面这段代码是针对 x86 架构寻找函数开头的代码,主要思路是搜索函数开头的特征 push rbp; mov rbp, rsp

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
def get_func_by_addr(addr, project, size=0x1000):
"""
根据给定的地址,查找该地址所在的函数的起始地址。

:param addr: 目标地址,想要查找的地址
:param project: angr 项目的实例
:param size: 查找范围的大小,默认为 0x1000 字节
:return: 该地址所在的函数的起始地址
"""
# 从目标地址往前加载指定大小的内存数据
data = project.loader.memory.load(addr - size, size)

# 在加载的数据中查找函数的起始指令(假设是 "push rbp; mov rbp, rsp" 作为函数的前导)
addr = addr - size + data.rfind(b"\x55\x48\x89\xE5") # 0x55 0x48 0x89 0xE5 是 "push rbp; mov rbp, rsp" 的机器码

# 返回查找到的函数起始地址
return addr

下面这段代码是针对 arm 架构寻找函数开头的代码,由于 arm 架构的机器码长度比较固定,因此可以精确的分析汇编代码。不过要注意的是 arm 架构的函数后面可能会有一些全局变量的地址指针,被识别为汇编可能会影响分析结果,这里简单的用经验规则过滤一下。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
def get_func_by_addr(addr, project):
"""
根据给定的地址,逐步向上查找,直到找到函数的起始地址。
假设函数的开始是通过检查指令 `push lr` 来确定的。

:param addr: 目标地址,想要查找的地址
:param project: angr 项目的实例
:return: 找到的函数起始地址
"""
while True:
# 读取从给定地址开始的 8 个字节,并反汇编为指令
insns: List[CsInsn] = list(CS.disasm(project.loader.memory.load(addr, 8), addr))

# 判断是否找到函数的起始指令
# 规则:第一条指令是 'push lr',第二条指令不是 'lsls'
if len(insns) > 1 \
and insns[0].mnemonic.startswith('push') \
and 'lr' in insns[0].op_str \
and insns[1].mnemonic != 'lsls': # 经验值,对应代码段中嵌入的地址表。
# 如果符合条件,返回当前地址作为函数的起始地址
return addr

# 如果不符合条件,继续向上移动地址(减去 2 字节)查找
addr -= 2

下面这段代码是针对 aarch64 架构寻找函数开头的代码,同样是搜索汇编实现的。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
def get_func_by_addr(addr, project):
"""
根据给定的地址,逐步向上查找,直到找到函数的起始地址。
假设函数的开始是通过检查指令 `stp x29, x30` 来确定的,通常这是 ARM64 函数的前导指令。

:param addr: 目标地址,想要查找的地址
:param project: angr 项目的实例
:return: 找到的函数起始地址
"""
while True:
# 读取从给定地址开始的 4 个字节,并反汇编为指令
insn: CsInsn = list(CS.disasm(project.loader.memory.load(addr, 4), addr))[0]

# 判断是否为 ARM64 函数的起始指令 "stp x29, x30"
if insn.mnemonic == 'stp' and 'x29, x30' in insn.op_str:
# 如果符合条件,返回当前地址作为函数的起始地址
return addr

# 如果不符合条件,继续向上移动地址(减去 4 字节)查找
addr -= 4

引用

前面定位函数起始地址的前提是需要有一个函数内部的地址,而我们通常是用一些特征数据(例如字符串)的引用来定位函数内部的地址的。

其中 arm32 架构由于全局变量的地址会被写到引用的函数后面,因此我们可以直接通过搜索全局变量的地址的方式来定位函数。而对于 x86 和 aarch64 架构则需要我们扫描汇编预处理出全局变量的引用关系。

x86 架构

在 x86 架构下,我们常见的字符串引用的汇编代码一般是如下两种形式:

  • lea 形式,适用于地址无关代码。对于这种形式被引用的字符串的地址在汇编指令的硬编码中体现不出来,因此不能直接通过搜索地址的方式定位到引用字符串的汇编代码,需要扫描分析汇编预处理引用表。

    1
    lea reg, [rip + offset];
  • mov 形式,直接将目标地址设置到寄存器中。对于这种形式被引用的字符串的地址在汇编指令的硬编码中自带字符串地址,因此可以通过在代码段中搜索字符串地址来定位。

    1
    mov reg, address;

最终的代码实现如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
import json
import os
from collections import defaultdict
from rich.progress import Progress # 用于显示进度条
import angr
from capstone import *
from capstone.x86 import *


class RefDictX86:
"""用于分析x86二进制文件并建立数据引用关系的工具类"""

def __init__(self, project: angr.project.Project):
self.project = project

# 根据二进制文件MD5生成缓存文件名
ref_file = f'{project.loader.main_object.md5.hex()}_ref.json'

# 如果存在缓存文件直接加载
if os.path.exists(ref_file):
self.ref_dict = {int(k): v for k, v in json.load(open(ref_file)).items()}
return

# 初始化引用字典并获取.text节数据
self.ref_dict = defaultdict(list)
text_section = [s for s in project.loader.main_object.sections if s.name == '.text'][0]
data = project.loader.memory.load(text_section.vaddr, text_section.filesize)

# 使用进度条显示反汇编进度
with Progress() as progress:
task = progress.add_task("[*] 分析引用关系...", total=text_section.filesize)
cs = Cs(CS_ARCH_X86, CS_MODE_64) # 初始化x64反汇编引擎
cs.detail = True # 启用详细模式获取操作数信息

disasm_offset = 0 # 反汇编偏移指针

# 遍历.text节的每条指令
while disasm_offset < text_section.filesize:
# 快速反汇编获取基础指令信息
for (addr, size, mnemonic, op_str) in cs.disasm_lite(data[disasm_offset:], text_section.vaddr + disasm_offset):
progress.update(task, advance=size)

# 处理RIP相对寻址的LEA指令(常见于字符串加载)
if mnemonic == 'lea' and 'rip' in op_str:
# 完整反汇编获取详细操作数信息
insn = next(cs.disasm(data[disasm_offset:disasm_offset + size], text_section.vaddr + disasm_offset))
# 验证LEA指令格式:lea reg, [rip+offset]
if (insn.operands[1].mem.base == X86_REG_RIP
and insn.operands[1].type == X86_OP_MEM):
# 计算实际内存地址 = 下条指令地址 + 偏移量
target_addr = insn.address + insn.size + insn.operands[1].mem.disp
self.ref_dict[target_addr].append(insn.address)

# 处理直接加载立即数的MOV指令(常见于全局变量访问)
elif mnemonic == 'mov' and '[' not in op_str: # 排除内存操作数
insn = next(cs.disasm(data[disasm_offset:disasm_offset + size], text_section.vaddr + disasm_offset))
# 验证MOV指令格式:mov reg, imm
if (insn.operands[1].type == X86_OP_IMM
and insn.operands[1].imm in self.project.loader.main_object.reverse_plt):
# 检查目标地址是否在.rodata节
sec = self.project.loader.main_object.find_section_containing(insn.operands[1].imm)
if sec and sec.name == '.rodata':
self.ref_dict[insn.operands[1].imm].append(insn.address)

disasm_offset += size # 移动反汇编指针

# 将结果序列化到JSON文件
json.dump(self.ref_dict, open(ref_file, 'w+'))

def str_ref(self, s: str) -> list:
"""查找字符串引用地址"""
# 在内存中搜索字符串
str_addrs = list(self.project.loader.memory.find(s.encode()))
print(f"[*] 字符串地址: {' '.join(f'0x{addr:x}' for addr in str_addrs)}")

# 收集所有引用该字符串的指令地址
return [ref for addr in str_addrs for ref in self.ref_dict.get(addr, [])]


# 测试用例
if __name__ == "__main__":
# 示例:分析文件"init"中"r+"字符串的引用
ref_dict = RefDictX86(angr.Project("init")) # 初始化分析器
print("引用指令地址:", ' '.join(f'0x{addr:x}' for addr in ref_dict.str_ref("r+")))

AMD64 架构

  • 未开启 PIE(ET_EXEC / main_object.pic == False
    不做全量反汇编建索引;按需检索:当你查某个地址(或字符串地址)时,直接在 .text按字节搜索该地址的小端编码
    • mov reg, imm64 → 搜索 8 字节小端 imm64
    • 绝对内存操作(moffs/无基址)常见 32 位位移 → 额外搜索 4 字节小端 addr & 0xffffffff
      命中点再可选用一次小片段反汇编做验证(默认开启,代价很小)。
  • 开启 PIE(ET_DYN / main_object.pic == True
    采用你建议的思路:先用 disasm_lite 扫一遍,只有当满足
    • 是我们关注的指令(默认 mov/lea/cmp/add/sub),且
    • 操作数字符串里出现 [(内存访问)或(mov 且不含 [,可能是 imm

时,再对该条指令切片做一次 detail 反汇编,提取:

  • RIP 相对引用:target = insn.address + insn.size + disp
  • 立即数像指针:mov reg, immimm 落在非 .text
  • (兼容)绝对内存:mem.base==0 && mem.index==0 时的 disp(部分编译器场景会出现)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
import json
import os
from collections import defaultdict
from typing import List, Dict, Iterable

import angr
from rich.progress import Progress
from capstone import Cs, CS_ARCH_X86, CS_MODE_64, CS_OP_IMM, CS_OP_MEM
from capstone.x86 import X86_REG_RIP


class RefDictAMD64PIEAware:
"""
x86-64 引用查找(PIE 感知优化):
- 非 PIE:不建索引;按需在 .text 里搜索 8/4 字节小端地址,命中点可选反汇编校验
- PIE:一次性快速遍历(disasm_lite),命中候选再 detail 反汇编,建立 ref_dict 索引
"""
def __init__(self, project: angr.project.Project, cache_dir: str = ".", verify_nonpie_hits: bool = True):
self.project = project
self.mo = project.loader.main_object
self.is_pie = bool(getattr(self.mo, "pic", False)) # CLE 的 ELF 对象有 .pic 属性
self.verify_nonpie_hits = verify_nonpie_hits

# 共享的 .text 缓冲
text = next(s for s in self.mo.sections if s.name == ".text")
self.text_base = text.vaddr
self.text_size = text.filesize
self.text_blob = project.loader.memory.load(self.text_base, self.text_size)

self.ref_dict: Dict[int, List[int]] = defaultdict(list)

if self.is_pie:
# 仅在 PIE 下才建索引
md5hex = self.mo.md5.hex()
cache = os.path.join(cache_dir, f"{md5hex}_amd64_pie_refs.json")
if os.path.exists(cache):
self.ref_dict = {int(k): v for k, v in json.load(open(cache)).items()}
else:
self._build_refs_for_pie()
json.dump(self.ref_dict, open(cache, "w"))

# -----------------------------
# 公共 API
# -----------------------------
def str_ref(self, s: str) -> List[int]:
"""查找字符串 s 被引用的指令地址"""
addrs = list(self.project.loader.memory.find(s.encode()))
if not addrs:
print("[*] 未在内存中找到该字符串")
return []
print("[*] 字符串地址:", " ".join(f"0x{a:x}" for a in addrs))
refs: List[int] = []
for a in addrs:
refs.extend(self.addr_ref(a))
return refs

def addr_ref(self, addr: int) -> List[int]:
"""查找对某个地址的引用(指令地址列表)"""
if self.is_pie:
return self.ref_dict.get(addr, [])
else:
# 非 PIE:按需检索(极快),必要时做轻量验证
return list(self._scan_nonpie_code_for_address(addr))

# -----------------------------
# 内部:PIE 模式建索引(一次性)
# -----------------------------
def _build_refs_for_pie(self):
cs_lite = Cs(CS_ARCH_X86, CS_MODE_64)
cs_lite.detail = False
cs_lite.skipdata = True

cs_full = Cs(CS_ARCH_X86, CS_MODE_64)
cs_full.detail = True
cs_full.skipdata = True

# 关注的候选指令(你可自行调整)
watch = {"mov", "lea", "cmp", "add", "sub"}

def in_nontext(a: int):
sec = self.mo.find_section_containing(a)
return sec if sec and sec.name != ".text" else None

with Progress() as pg:
task = pg.add_task("[*] PIE: 快速扫描并建立引用索引...", total=self.text_size)
for (iaddr, insn_sz, mnem, op_str) in cs_lite.disasm_lite(self.text_blob, self.text_base):
pg.update(task, advance=insn_sz)

if mnem not in watch:
continue

# 快速筛:内存访问看 '[';立即数 mov 看不含 '['
need_full = False
reason_mem = False
reason_movimm = False

if '[' in op_str:
need_full = True
reason_mem = True
elif mnem == "mov":
need_full = True
reason_movimm = True

if not need_full:
continue

off = iaddr - self.text_base
if off < 0 or off + insn_sz > len(self.text_blob):
continue

insn = next(cs_full.disasm(self.text_blob[off: off + insn_sz], iaddr), None)
if not insn:
continue

# RIP 相对内存
for op in insn.operands:
if op.type == CS_OP_MEM and op.mem.base == X86_REG_RIP:
target = insn.address + insn.size + op.mem.disp
self.ref_dict[target].append(insn.address)

# 兼容:绝对内存(极少,但留个口)
for op in insn.operands:
if op.type == CS_OP_MEM and op.mem.base == 0 and op.mem.index == 0:
tgt = op.mem.disp & ((1 << 64) - 1)
if in_nontext(tgt):
self.ref_dict[tgt].append(insn.address)

# 立即数像指针(mov reg, imm)
if reason_movimm and len(insn.operands) >= 2 and insn.operands[1].type == CS_OP_IMM:
imm = int(insn.operands[1].imm)
if in_nontext(imm):
self.ref_dict[imm].append(insn.address)

# -----------------------------
# 内部:非 PIE 模式按需检索
# -----------------------------
def _scan_nonpie_code_for_address(self, target: int) -> Iterable[int]:
"""
在 .text 里直接按字节搜索:
- 8 字节小端:匹配 mov reg, imm64 等
- 4 字节小端:匹配绝对内存 disp32(不少非 PIE 场景)
若开启 verify_nonpie_hits,会对命中点附近做一次极小切片反汇编以减少误报
"""
hits = set()

le64 = target.to_bytes(8, "little", signed=False)
le32 = (target & 0xffffffff).to_bytes(4, "little", signed=False)

# 搜索 8 字节立即数
start = 0
while True:
idx = self.text_blob.find(le64, start)
if idx == -1:
break
hits.add(self.text_base + idx)
start = idx + 1

# 搜索 4 字节(绝对内存 disp32)
start = 0
while True:
idx = self.text_blob.find(le32, start)
if idx == -1:
break
hits.add(self.text_base + idx)
start = idx + 1

if not hits:
return []

if not self.verify_nonpie_hits:
# 直接把字节命中处当作“可能的引用指令地址”
# 更精确的做法是把命中点向前回溯反汇编得到指令边界,下面 verify 会做
return sorted(hits)

# 轻量验证:对命中点附近做一次短窗口反汇编,确认该地址在操作数中出现
cs = Cs(CS_ARCH_X86, CS_MODE_64)
cs.detail = True
cs.skipdata = True

verified = set()
for hit in hits:
# 在命中点前回溯最多 10 字节,反汇编一小段直到越过命中点
# 这样能对齐到指令边界,拿到真正的“指令地址”
back = 10
win_start = max(self.text_base, hit - back)
off = win_start - self.text_base
code = self.text_blob[off: off + 32]

for insn in cs.disasm(code, win_start):
if insn.address + insn.size <= hit:
continue # 还没覆盖到命中点
# 命中点处于这条指令内,检查操作数是否包含 target
if self._insn_refs_target_nonpie(insn, target):
verified.add(insn.address)
break # 这条指令已覆盖命中点,结束

return sorted(verified)

@staticmethod
def _insn_refs_target_nonpie(insn, target: int) -> bool:
"""检查一条指令是否通过 imm64 / 绝对内存 disp32 引用了 target"""
for op in insn.operands:
if op.type == CS_OP_IMM:
if int(op.imm) == target:
return True
elif op.type == CS_OP_MEM:
# 无基址无索引,视作绝对地址(常见于非 PIE 的 moffs/disp32)
if op.mem.base == 0 and op.mem.index == 0:
disp = op.mem.disp & ((1 << 64) - 1)
# 绝对内存常见 32 位位移;这里直接等值判断
if disp == target or (disp & 0xffffffff) == (target & 0xffffffff):
return True
return False


if __name__ == "__main__":
# 示例:分析 ./init 对 "r+" 的引用
proj = angr.Project("init", auto_load_libs=False)
rd = RefDictAMD64PIEAware(proj, verify_nonpie_hits=True)
refs = rd.str_ref("r+")
print("引用指令地址:", " ".join(f"0x{a:x}" for a in refs))

AArch64 架构

aarch 架构可以参考前面计算内存操作数对应的地址的方法扫描汇编进行预处理。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
import json
import os
from collections import defaultdict
from rich.progress import Progress # 更现代的进度条库
import angr
from capstone import *
from capstone.arm64 import *


class RefDictARM64:
"""用于分析ARM64二进制文件并建立数据引用关系的工具类"""

def __init__(self, project: angr.project.Project):
self.project = project

# 根据二进制文件MD5生成缓存文件名
ref_file = f'{project.loader.main_object.md5.hex()}_ref.json'

# 如果存在缓存文件直接加载
if os.path.exists(ref_file):
with open(ref_file, 'r') as f:
self.ref_dict = {int(k): v for k, v in json.load(f).items()}
return

# 初始化引用字典并获取.text节数据
self.ref_dict = defaultdict(list)
text_section = next(s for s in project.loader.main_object.sections if s.name == '.text')
data = project.loader.memory.load(text_section.vaddr, text_section.filesize)

# 初始化反汇编引擎
cs = Cs(CS_ARCH_ARM64, CS_MODE_ARM)
cs.detail = True

# 使用上下文管理器管理进度条
with Progress() as progress:
task = progress.add_task("[*] 分析ARM64引用关系...", total=text_section.filesize)
reg_value = {} # 用于跟踪寄存器存储的基地址

disasm_offset = 0

for (address, size, mnemonic, op_str) in cs.disasm_lite(data[disasm_offset:], text_section.vaddr + disasm_offset):
progress.update(task, advance=size)
if mnemonic == 'adrp' or mnemonic == 'add':
insn = next(cs.disasm(data[disasm_offset:disasm_offset + size], text_section.vaddr + disasm_offset))

# 处理ADRP指令(页地址加载)
if insn.mnemonic == 'adrp':
if (insn.operands[0].type == ARM64_OP_REG and
insn.operands[1].type == ARM64_OP_IMM):
# 记录寄存器存储的页基地址
reg_value[insn.operands[0].reg] = insn.operands[1].imm

# 处理ADD指令(偏移地址计算)
elif insn.mnemonic == 'add':
if (insn.operands[0].type == ARM64_OP_REG and
insn.operands[1].type == ARM64_OP_REG and
insn.operands[2].type == ARM64_OP_IMM and
insn.operands[0].reg == insn.operands[1].reg and
insn.operands[1].reg in reg_value):
# 计算完整地址 = 页基地址 + 偏移量
base = reg_value[insn.operands[1].reg]
full_addr = base + insn.operands[2].imm
self.ref_dict[full_addr].append(insn.address)

# 分支指令清空寄存器跟踪状态
if mnemonic.startswith('b'):
reg_value.clear()

disasm_offset += size

# 保存结果到JSON文件
os.makedirs('refs', exist_ok=True)
with open(ref_file, 'w+') as f:
json.dump(self.ref_dict, f)

def str_ref(self, s: str) -> list:
"""查找字符串引用地址"""
# 在内存中搜索字符串
str_addrs = list(self.project.loader.memory.find(s.encode()))
print(f"[*] 字符串地址: {' '.join(f'0x{addr:x}' for addr in str_addrs)}")

# 收集所有引用该字符串的指令地址
return [ref for addr in str_addrs for ref in self.ref_dict.get(addr, [])]


# 测试用例
if __name__ == "__main__":
# 示例:分析文件"init"中"r+"字符串的引用
ref_dict = RefDictARM64(angr.Project("init", auto_load_libs=False)) # 初始化分析器
print("引用指令地址:", ' '.join(f'0x{addr:x}' for addr in ref_dict.str_ref("r+")))

符号执行

符号执行原理

基本概念

符号执行(Symbolic Execution)是一种程序分析技术,它通过使用符号(而不是具体的值)来代替程序中的输入数据,在程序执行时跟踪符号变量的值。这使得我们能够推理出程序的行为,而不需要实际运行它。符号执行可以帮助发现程序的潜在问题,如漏洞、错误和安全问题,尤其在静态分析、漏洞挖掘和逆向工程等领域有广泛应用。

符号执行中的 符号状态路径约束 是符号执行中两个非常重要的概念,它们帮助我们表达程序的执行过程和各种条件。

  • 符号状态(Symbolic State):当前状态所有参数的集合,用 σσ 表示。集合中的每个元素用表示初始参数的变量表示。
  • 路径约束(Path Constraint):到达当前路径需要表示初始参数满足的关系,通常用 PC\text{PC} 表示。

例如下面的程序:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
#include <bits/stdc++.h>

using namespace std;

int main() {
int x, y, z;
cin >> x >> y;
z = 2 * y;
if (x == z) {
if (x > y + 10) {
cout << "Path-1";
} else {
cout << "Path-2";
}
} else {
cout << "Path-3";
}
return 0;
}

对应的程序框图如下:

simulation

我们用 xsimx_{sim}ysimy_{sim} 分别表示初始输入的参数 xy 。如果程序执行到 Path-1 ,则:

  • σ={x=xsim,y=ysim,z=2ysim}σ=\{x=x_{sim},y=y_{sim},z=2\cdot y_{sim}\}
  • PC=(xsim=2ysim)(xsim>ysim+10)\text{PC}=(x_{sim}=2\cdot y_{sim}) \wedge ( x_{sim}>y_{sim}+10)

约束求解

即根据符号执行求得的执行到目标位置时的状态,反推出初始时假设的各个变量的值。

例如上面计算出执行到 Path-1 时的 σσ 和 PC 。如果执行到 Path-1 则应当满足 PC 为真,进一步推出 xsim=22,ysim=11x_{sim} = 22,y_{sim}=11 为一组合法解。

为了进行约束求解,angr 内置了 z3 约束求解器(封装为 claripy)。

动态符号执行

由于 angr 分析基于的是低级语言,会涉及内存、寄存器等结构,如果全部符号化会使得路径约束变得十分复杂且没有必要。

因此 angr 采取动态符号执行(Dynamic Symbloic Execution)或者叫做混合执行(Concolic Execution)的方式,即将关键变量符号化,其他变量都赋一个合理的初始值。

angr 在默认情况下,只有从标准输入流中读取的数据会被符号化,其他数据都是具有实际值的。

符号执行引擎(Claripy)

Claripy 是由 z3 封装的二进制分析框架 angr 的核心符号执行引擎,专注于 符号表达式操作约束求解。它为二进制分析提供了一套高级抽象接口,简化了符号变量管理、约束构建和求解过程,使复杂的符号执行任务更易实现。

位向量创建

位向量(Bitvectors) 是符号执行中一个非常核心的概念,特别是在像 angr 这样的符号执行框架中,位向量用于表示程序中变量的值和各种计算结果。

位向量是一个由多个比特(bit)组成的向量。在符号执行的上下文中,位向量被用来表示程序中未确定的数值(如变量、内存中的数据等)。每个比特的位置可以表示不同的数值或者数据状态。

我们可以通过 Claripy 创建位向量常量:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
>>> import claripy

# 64 位位向量,具体值为 1 和 100
>>> one = claripy.BVV(1, 64)
>>> one
<BV64 0x1>
>>> one._model_concrete.value
1

>>> one_hundred = claripy.BVV(100, 64)
>>> one_hundred
<BV64 0x64>

# 创建一个 27 位位向量,具体值为 9
>>> weird_nine = claripy.BVV(9, 27)
>>> weird_nine
<BV27 0x9>

除了位向量常量,我们还可以创建位向量符号:

1
2
3
4
5
6
7
# 创建一个名为 "x" 的 64 位位向量符号
>>> x = claripy.BVS("x", 64)
>>> x
<BV64 x_9_64>
>>> y = claripy.BVS("y", 64)
>>> y
<BV64 y_10_64>

z3 支持 IEEE754 浮点数理论,因此 angr 也可以使用它们。主要的区别是,浮点数不是通过宽度来表示的,而是通过 claripy.fp.FSORT_FLOAT/claripy.fp.FSORT_DOUBLE 来表示。你可以使用 FPVFPS 来创建浮点符号和浮点值。

1
2
3
4
5
6
7
>>> a = claripy.FPV(3.2, claripy.fp.FSORT_DOUBLE)  # 创建浮点值
>>> a
<FP64 FPV(3.2, DOUBLE)>

>>> b = claripy.FPS('b', claripy.fp.FSORT_DOUBLE) # 创建浮点符号
>>> b
<FP64 FPS('FP_b_0_64', DOUBLE)>

浮点数和整数类型的向量可以互相转换。

如果是使用 raw_to_bvraw_to_fp 转换则表示的是数据不变,数据的解释方式改变(就像你将浮点数指针转换为整数指针或反之一样)。

1
2
3
4
5
6
7
8
9
>>> a.raw_to_bv()
<BV64 0x400999999999999a>
>>> b.raw_to_bv()
<BV64 fpToIEEEBV(FPS('FP_b_0_64', DOUBLE))>

>>> claripy.BVV(0, 64).raw_to_fp()
<FP64 FPV(0.0, DOUBLE)>
>>> claripy.BVS('x', 64).raw_to_fp()
<FP64 fpToFP(x_1_64, DOUBLE)>

如果是类型转换则需要使用 val_to_fpval_to_bv

1
2
3
4
5
6
>>> a
<FP64 FPV(3.2, DOUBLE)>
>>> a.val_to_bv(12)
<BV12 0x3>
>>> a.val_to_bv(12).val_to_fp(claripy.fp.FSORT_FLOAT)
<FP32 FPV(3.0, FLOAT)>

位向量运算

同样长度的位向量可以进行运算,其中 Pyhton 的整数类型也可以参与运算,在运算过程中会被强制转换为适当的类型。

1
2
3
4
5
6
>>> one + one_hundred
<BV64 0x65>
>>> one_hundred + 0x100
<BV64 0x164>
>>> one_hundred - one*200
<BV64 0xffffffffffffff9c>

但是,你不能执行 one + weird_nine,因为操作数位向量的长度不同,这是一个类型错误。然而,你可以扩展 weird_nine 使它具有适当的位数:

1
2
3
4
>>> weird_nine.zero_extend(64 - 27)
<BV64 0x9>
>>> one + weird_nine.zero_extend(64 - 27)
<BV64 0xa>

zero_extend 将在位向量的左侧填充给定数量的零位。你还可以使用 sign_extend 来用最高位的副本进行填充,保持位向量在二进制补码有符号整数语义下的值。

位向量符号同样也可以参与到位向量运算中。你可以对它们进行任意算术运算,但你不会得到一个数字,而是得到一个 AST(抽象语法树)。

1
2
3
4
5
6
7
8
>>> x + one
<BV64 x_9_64 + 0x1>

>>> (x + one) / 2
<BV64 (x_9_64 + 0x1) / 0x2>

>>> x - y
<BV64 x_9_64 - y_10_64>

每个 AST 都有 .op.args 属性:

  • op 是一个字符串,表示正在执行的操作。
  • args 是该操作接受的输入值。

除非 opBVVBVS(或其他少数几种情况),否则 args 都是其他的 AST,最终树将终止于 BVVBVS

AST

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
>>> tree = (x + 1) / (y + 2)
>>> tree
<BV64 (x_9_64 + 0x1) / (y_10_64 + 0x2)>
>>> tree.op
'__floordiv__'
>>> tree.args
(<BV64 x_9_64 + 0x1>, <BV64 y_10_64 + 0x2>)
>>> tree.args[0].op
'__add__'
>>> tree.args[0].args
(<BV64 x_9_64>, <BV64 0x1>)
>>> tree.args[0].args[1].op
'BVV'
>>> tree.args[0].args[1].args
(1, 64)

另外浮点数向量也支持数学运算:

1
2
3
4
5
6
7
8
>>> a + b
<FP64 fpAdd('RNE', FPV(3.2, DOUBLE), FPS('FP_b_0_64', DOUBLE))>

>>> a + 4.4
<FP64 FPV(7.6000000000000005, DOUBLE)>

>>> b + 2 < 0
<Bool fpLT(fpAdd('RNE', FPS('FP_b_0_64', DOUBLE), FPV(2.0, DOUBLE)), FPV(0.0, DOUBLE))>

符号约束(Symbolic Constraints)

对任何两个相同类型的 AST 执行比较操作将生成另一个 AST。这个新生成的 AST 不是位向量,而是一个符号布尔值。

注意

AST 默认情况下的比较是无符号的。最后一个例子中的 -5 会被强制转换为 <BV64 0xfffffffffffffffb>,它显然不小于 100。如果你想要进行有符号的比较,可以使用 one_hundred.SGT(-5)(即“有符号大于”)。

1
2
3
4
5
6
7
8
9
10
11
12
>>> x == 1
<Bool x_9_64 == 0x1>
>>> x == one
<Bool x_9_64 == 0x1>
>>> x > 2
<Bool x_9_64 > 0x2>
>>> x + y == one_hundred + 5
<Bool (x_9_64 + y_10_64) == 0x69>
>>> one_hundred > 5
<Bool True>
>>> one_hundred > -5
<Bool False>

符号布尔值可以通过 claripy.is_true/claripy.is_false 或本身的 is_trueis_false 方法来判断真假。

注意

is_trueis_false 只是用来判断符号布尔值是否永真或永假。对于结果不确定的符号布尔值两个方法都会返回 False

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
>>> yes = one == 1
>>> no = one == 2
>>> maybe = x == y
>>> claripy.is_true(yes) # 等价于 yes.is_true()
True
>>> claripy.is_false(yes)
False
>>> claripy.is_true(no)
False
>>> claripy.is_false(no)
True
>>> claripy.is_true(maybe)
False
>>> claripy.is_false(maybe)
False

另外符号布尔值不应直接在 if 语句或 while 语句的条件中使用,因为答案可能没有具体的真值,并且即使有具体真值也会触发异常。

通常情况下,Claripy 支持所有常见的 Python 操作符(如 +-|== 等),并通过 Claripy 实例对象提供了额外的一些操作。这些操作是 Claripy 提供的用于处理符号表达式的基本操作,通过它们可以进行位运算、条件判断、扩展和提取等操作,在符号执行和分析中非常有用。

名称 描述 示例
LShR 逻辑右移位操作(适用于位表达式,如 BV、SI)。 claripy.LShR(x, 10)
SignExt 对位表达式进行符号扩展。 claripy.SignExt(32, x)x.sign_extend(32)
ZeroExt 对位表达式进行零扩展。 claripy.ZeroExt(32, x)x.zero_extend(32)
Extract 从位表达式中提取指定的位(从右侧零索引开始,包含边界)。 提取 x 最右边的字节:claripy.Extract(7, 0, x)x[7:0]
Concat 将多个位表达式拼接成一个新的位表达式。 claripy.Concat(x, y, z)
RotateLeft 将位表达式左旋转。 claripy.RotateLeft(x, 8)
RotateRight 将位表达式右旋转。 claripy.RotateRight(x, 8)
Reverse 对位表达式进行字节序反转。 claripy.Reverse(x)x.reversed
And 逻辑与(适用于布尔表达式)。 claripy.And(x == y, x > 0)
Or 逻辑或(适用于布尔表达式)。 claripy.Or(x == y, y < 10)
Not 逻辑非(适用于布尔表达式)。 claripy.Not(x == y) 等同于 x != y
If 条件选择(If-then-else)。 选择 xy 中的最大值:claripy.If(x > y, x, y)
ULE 无符号小于或等于。 检查 x 是否小于或等于 yclaripy.ULE(x, y)
ULT 无符号小于。 检查 x 是否小于 yclaripy.ULT(x, y)
UGE 无符号大于或等于。 检查 x 是否大于或等于 yclaripy.UGE(x, y)
UGT 无符号大于。 检查 x 是否大于 yclaripy.UGT(x, y)
SLE 有符号小于或等于。 检查 x 是否小于或等于 yclaripy.SLE(x, y)
SLT 有符号小于。 检查 x 是否小于 yclaripy.SLT(x, y)
SGE 有符号大于或等于。 检查 x 是否大于或等于 yclaripy.SGE(x, y)
SGT 有符号大于。 检查 x 是否大于 yclaripy.SGT(x, y)

约束求解(Constraint Solving)

你可以将任何符号布尔值视为对符号变量有效值的断言,通过将其作为约束添加到状态中。然后,你可以通过请求对符号表达式的求值,查询符号变量的有效值。

1
2
3
4
5
6
7
8
>>> solver = claripy.Solver()
>>> solver.add(x > y)
>>> solver.add(y > 2)
>>> solver.add(10 > x)
>>> solver.eval(x, 4) # 第二个参数表示获取的解的个数
(8, 9, 4, 5)
>>> solver.eval(x + y, 3) # 支持求解表达式
(7, 15, 16)

如果我们添加了相互冲突或矛盾的约束,导致没有任何值可以赋给变量以满足约束条件,状态将变得不可满足(unsat),查询时会引发异常。你可以通过 solver.satisfiable() 检查状态是否可满足。

1
2
>>> solver.satisfiable()
True

另外如果我们想要获取解的最大或最小值则应当使用 maxmin 方法:

1
2
3
4
>>> solver.max(x)
9
>>> solver.min(x)
4

状态(States)

Project 对象只代表程序的一个“初始化镜像”。当你在 angr 中执行程序时,你实际上是在操作一个代表程序状态的对象——SimState

状态创建

我们可以通过 factoryentry_state 创建状态。

1
2
>>> state = proj.factory.entry_state()
<SimState @ 0x401670>

当然 entry_state 只是项目工厂提供的多个状态构造函数之一,常见的状态构造函数有:

  • .blank_state()构造一个“空白”状态,数据大部分没有初始化。当访问未初始化的数据时,会返回一个没有约束的符号值。适用于要完全控制初始条件的场景。
    • addr :状态应该开始的地址,而不是入口点。
  • .entry_state():构造一个准备从主二进制的入口点开始执行的状态。
    • argc :用作程序 argc 的自定义值,可以是整数或比特向量。如果未提供,则默认为 args 的长度。
    • args :一个值的列表,用作程序的 argv。可以是混合字符串和比特向量。
    • env :一个字典,用作程序的环境。键和值都可以是混合的字符串和比特向量。
    • stdin :程序的输入流。可以是字符串或比特向量,不过最好长度给的要足够。
  • .full_init_state():构造一个准备执行所有需要在主二进制入口点之前运行的初始化器的状态,例如共享库构造函数或预初始化器。当这些完成后,它将跳转到入口点。它可以接受 entry_state 可以提供的任何参数,除了 addr
  • .call_state():构造一个准备执行给定函数的状态。
    • addr :状态应该开始的地址,而不是入口点。
    • args :任何额外的位置参数将作为函数调用的参数。

SimState 包含了程序的内存、寄存器、文件系统数据等内容……任何在执行过程中可能被修改的“实时数据”都存储在这个状态中。

1
2
3
4
5
6
7
8
9
10
11
>>> state.regs.rip        # 获取当前的指令指针
<BV64 0x401670>
>>> state.regs.rax
<BV64 0x1c>
>>> state.mem[proj.entry].int.resolved # 将入口点处的内存作为 C int 解释
<BV32 0x8949ed31>

# 标准输入内容
# state.posix.dumps(fileno) 获取对应文件描述符上的流
>>> state.posix.dumps(0)
b''

可以看到无论是内存还是寄存器,angr 的 SimState 都是用位向量的形式来维护。这种策略方便符号执行完之后进行约束求解。另外就是除了输入和用户指定的数据外,其余数据都是给定一个合理的初始值而不都是符号化,这样可以极大的简化最终生成的表达式的复杂程度。

内存设置

在 angr 中,state.memstate.memory 是用于操作内存的两个核心接口,分别提供 类型化内存访问原始字节级操作 的功能。

state.memory 提供 原始字节级操作,这意味着你可以直接访问内存的字节,而不需要考虑类型的转换。state.memory 用于处理低级操作,适合那些需要进行细粒度控制的场景,或者需要直接修改内存数据的情况。

  • load(addr, size):从地址 addr 读取 size 字节,返回位向量(claripy.BV)。
  • store(addr, data):将数据 data(位向量或字节)写入地址 addr
1
2
3
4
>>> state = proj.factory.blank_state()
>>> state.memory.store(0x4000, s.solver.BVV(0x0123456789abcdef0123456789abcdef, 128))
>>> state.memory.load(0x4004, 6)
<BV48 0x89abcdef0123>

注意

state.memory 的主要用途是加载和存储数据块,没有附加语义,因此数据默认按照“大端序”读写。如果你想对加载或存储的数据进行字节交换,你可以传递一个关键字参数 endness

endness 应该是 archinfo 包中的 Endness 枚举的成员,该包用于保存关于 angr 中 CPU 架构的声明性数据。此外,正在分析的程序的字节序可以通过 arch.memory_endness 获取,比如 state.arch.memory_endness

1
2
3
>>> import archinfo
>>> s.memory.load(0x4000, 4, endness=archinfo.Endness.LE)
<BV32 0x67452301>

state.mem 提供 类型化内存访问,允许你对内存进行更高层次的操作,通常与寄存器、结构体、数组等数据结构的交互更为便捷。

注意

  • state.mem 赋值的时候可以使用 bytes、数字、位向量,但是位向量要确保类型长度一致。
  • state.mem 按照二进制默认的大小端序读写。
1
2
3
4
5
6
7
8
>>> state = proj.factory.blank_state()
>>> state.mem[0x4000].int64_t = 0xdeadbeefcafebabe
>>> state.mem[0x4000].uint32_t
<uint32_t <BV32 0xcafebabe> at 0x4000>
>>> state.mem[0x4000].uint32_t.resolved
<BV32 0xcafebabe>
>>> state.mem[0x4000].uint32_t.resolved._model_concrete.value
0xcafebabe

寄存器设置

和内存接口一样,angr 的寄存器接口也有 state.regsstate.registers 两种。和 state.memory 一样,state.registers 也提供了没有具体类型的底层数据访问接口,因为 angr 的寄存器本质上也是通过某个地址空间的内存来模拟的。

不过在实际使用中我们通常还是使用 state.regs 接口来读写寄存器。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
>>> import angr, claripy
>>> proj = angr.Project('/bin/true')
>>> state = proj.factory.entry_state()

# 将 rsp 复制到 rbp
>>> state.regs.rbp = state.regs.rsp

# 将 rdx 存储到内存地址 0x1000
>>> state.mem[0x1000].uint64_t = state.regs.rdx

# 解引用 rbp
>>> state.regs.rbp = state.mem[state.regs.rbp].uint64_t.resolved

# 执行 add rax, qword ptr [rsp + 8]
>>> state.regs.rax += state.mem[state.regs.rsp + 8].uint64_t.resolved

文件设置

SimFileangr 中用于模拟文件操作的类,它实现了对文件的模拟,包括文件读取、写入、以及其他文件操作。它设计的目标是模拟磁盘文件的行为,并且允许符号执行引擎(symbolic execution engine)对文件的内容和文件系统操作进行符号化处理。

SimFile 构造函数中常用的参数有:

  • name :文件的名称,用于标识文件。这个名称通常是文件路径的一部分。
  • content :可选的初始内容,可以是字符串或者位向量(bitvector)。如果没有提供内容,文件内容将默认为零。
  • size :可选的文件大小。如果没有提供大小,文件大小默认为零。如果提供了 content,则文件大小将根据内容的大小确定。

例如下面的示例代码,我们将 password.txt 这个文件符号化,这样如果程序的执行受到了该文件内容的影响,那么我们就可以在目标状态下求解文件的内容。

1
2
3
4
>>> state = proj.factory.entry_state()
>>> password = claripy.BVS('password', 0x40)
>>> sim_file = angr.SimFile(name='password.txt', content=password, size=0x40)
>>> state.fs.insert('password.txt', sim_file)

仿真管理器(Simulation Managers)

angr 中,仿真管理器(Simulation Managers)是用于管理模拟状态(SimState)的核心组件之一。仿真管理器负责维护符号执行过程中所有可能的路径,并为每个路径创建并管理相应的状态。

仿真管理器创建

仿真管理器通过 factorysimulation_manager 构造函数生成,该函数接收一个状态或状态列表。一个仿真管理器可以包含多个状态堆栈。默认的状态堆栈是 active,它使用我们传入的状态进行初始化。

1
2
3
4
>>> simgr = proj.factory.simulation_manager(state)
<SimulationManager with 1 active>
>>> simgr.active
[<SimState @ 0x401670>]

在 angr 当中,不同的状态被组织到 simulation manager 的不同的 stash 当中,我们可以按照自己的需求进行步进、过滤、合并、移动等。在 angr 当中一共有以下几种 stash:

  • simgr.active:活跃的状态列表。在未指定替代的情况下会被模拟器默认执行。
  • simgr.deadended:死亡的状态列表。当一个状态无法再被继续执行时(例如没有有效指令、无效的指令指针、不满足其所有的后继(successors))便会被归入该列表。
  • simgr.pruned:被剪枝的状态列表。在指定了 LAZY_SOLVES 时,状态仅在必要时检查可满足性,当一个状态在指定了 LAZY_SOLVES 时被发现是不可满足的(unsat),状态层(state hierarchy)将会被遍历以确认在其历史中最初变为不满足的时间,该点及其所有后代都会被 剪枝 (pruned)并放入该列表。
  • simgr.unconstrained:不受约束的状态列表。当创建 SimulationManager 时指定了 save_unconstrained=True,则被认为不受约束的(unconstrained,即指令指针被用户数据或其他来源的符号化数据控制)状态会被归入该列表。
  • simgr.unsat:不可满足的状态列表。当创建 SimulationManager 时指定了 save_unsat=True,则被认为无法被满足的(unsatisfiable,即存在约束冲突的状态,例如在同一时刻要求输入既是"AAAA" 又是 "BBBB")状态会被归入该列表。

还有一种不是 stash 的状态列表——errored,若在执行中产生了错误,则状态与其产生的错误会被包裹在一个 ErrorRecord 实例中(可通过 record.staterecord.error 访问),该 record 会被插入到 errored 中,我们可以通过 record.debug() 启动一个调试窗口。

我们可以使用 stash.move() 来在 stash 之间转移放置状态,用法如下:

1
>>> simgr.move(from_stash = 'unconstrained', to_stash = 'active')

在转移当中我们还可以通过指定 filter_func 参数来进行过滤:

1
2
3
4
>>> def filter_func(state):
... return b'Right!' in state.posix.dumps(1)
...
>>> simgr.move(from_stash = 'unconstrained', to_stash = 'active', filter_func = filter_func)

stash 本质上就是个 list,因此在初始化时我们可以通过字典的方式指定每个 stash 的初始内容:

1
2
3
4
5
>>> simgr = proj.factory.simgr(init_state,
... stashes = {
... 'active':[init_state],
... 'found':[],
... })

路径探索

仿真管理器以基本块为单位对程序进行符号执行,对应的方法为 simgr.step()。每当仿真管理器调用一次 step 方法时:

  • 内部维护的 active 列表中的所有活跃状态都会执行一个基本块。
  • 每个状态在执行完一个基本块后根据基本块后根据执行的结果决定状态是否分裂或从 active 中移除。

我们可以循环调用 simgr.step() 然后遍历 active 列表判断是否有执行到我们预想的目标地址的状态。然后再对执行到目标地址的状态求解所需的输入。

1
2
3
4
5
6
while len(simgr.active):
for active in simgr.active:
if active.addr == target_addr:
# 执行到目标地址
# [...]
simgr.step()

上述过程实际上在仿真管理器中被封装成一个路径探索函数 simgr.explore()

explore 函数主要有两个参数:

  • find :一个地址或条件,表示我们希望探索到的目标状态。当仿真管理器的任何路径到达该地址时,仿真过程会停止或返回该路径。
  • avoid :一个地址或条件,表示我们希望避免的状态。即仿真管理器会尽量避免路径到达此地址或条件,通常用于避开错误路径或崩溃点。

提示

findavoid 可以接受多种类型的参数:

  • 如果参数类型是数字表示的是地址,即仿真管理器应当或不应当执行到的地址。

  • 如果参数是一个回调函数(或者 lambda 表达式),则会根据函数的返回结果对当前探索的路径进行剪枝。

    1
    2
    3
    4
    simgr.explore(
    find=lambda state: b'Good Job.' in state.posix.dumps(1),
    avoid=lambda state: b'Try again.' in state.posix.dumps(1)
    )

simgr.explore 执行完之后所有能执行到目标地址的状态都会放到 simgr.found 列表中。

另外仿真管理器还提供了多种技术来防止路径探索过程中出现路径爆炸的问题。例如出自2014年的一篇论文 Enhancing Symbolic Execution with Veritesting 的路径归并算法:

1
2
3
4
5
# 在创建仿真管理器的时候指定开启 veritesting
>>> simgr = proj.factory.simgr(state, veritesting=True)

# 另一种方式,可以通过 use_technique 方法使用。
>>> simgr.use_technique(angr.exploration_techniques.Veritesting())

路径归并算法主要是结合了先前两种符号执行算法 DSE(动态符号执行)和 SSE(静态符号执行)的优缺点:

  • 动态符号执行(DSE) :DSE 在执行过程中针对每一条路径进行符号执行,能够精确模拟每个路径的执行。然而,当程序包含大量条件分支时,路径数量会呈指数级增长,导致路径爆炸的问题,这对计算资源造成很大的压力。
  • 静态符号执行(SSE) :SSE 在静态分析阶段通过控制流图(CFG)来处理路径,通常能减少路径的数量,避免路径爆炸问题。然而,它在处理包含复杂系统调用、间接跳转或其他难以静态推理的语句时效果较差(很难用符号约束表示整个程序的逻辑)。

Veritesting 算法结合了动态符号执行和静态符号执行的优势。当程序中遇到不适合静态分析的部分(如系统调用、间接跳转等),可以切换到静态符号执行;而对于可以精确分析的部分,则使用动态符号执行,从而提高了符号执行的效率和精度。同时 Veritesting 使用路径合并技术,将多个路径合并成一个路径,避免了 DSE 中路径数量爆炸的问题。通过合并路径,Veritesting 在保持精确度的同时,显著减少了需要处理的路径数量。当然具体的细节还得阅读论文。

另外根据官方的说法,Versitesting 通常与其他 exploration techniques 不兼容。

Note that it frequenly doesn’t play nice with other techniques due to the invasive way it implements static symbolic execution.

函数 hook

仿真管理器在路径探索的过程中,可能因为某个函数导致路径爆炸。例如:

  • 程序自身实现的函数 :例如字符串比较函数,在比较到不同字符时跳出循环。如果该函数被符号执行,那么每循环一次所有状态都会因为跳出和不跳出循环两种情况而“分裂”一次。

  • 静态链接的程序中调用的比较复杂的库函数 :例如 malloc。这些函数在静态链接的程序中无法自动 hook,因为 angr 默认只会自动 hook 动态链接的库函数。

    在 angr 中,动态链接的程序调用的库函数会被自动 hook 为 angr 自身实现的库函数,这样可以有效避免路径爆炸问题。但是对于静态链接的程序,即使有调试符号 angr 也不会去自动 hook 这些函数,从而可能导致路径爆炸的问题。

为了解决这一问题,我们需要对造成路径爆炸的函数进行 hook。angr 提供了两种主要的方式来 hook 函数:proj.hookSimProcedure

project.hook 方法类似于二进制层面的 hook,即将指定位置的指定长度的二进制指令替换为调用我们自己实现的 Python 函数。具体来说,可以通过以下方式来 hook 掉对应的 call 指令:

1
project.hook(addr = call_insn_addr, hook = my_function, length = n)
  • call_insn_addr :被 hook 的 call 指令的地址。
  • my_function :我们自定义的 Python 函数。
  • lengthcall 指令的长度。

其中我们的自定义函数应该接受 state 作为参数,我们可以通过操作 state 模拟该函数对程序执行状态造成的影响。

1
2
3
4
5
def my_hook_func(state):
# 执行自定义的操作,这里是一个示例
state.regs.eax = 0xdeadbeef

proj.hook(addr = 0x5678, hook = my_hook_func, length = 5)

另外 angr 还支持注解的方式进行 hook,下面这段代码与前面的代码等价:

1
2
3
4
@project.hook(0x5678, length=5)
def my_hook_func(state):
# 执行自定义的操作,这里是一个示例
state.regs.eax = 0xdeadbeef

SimProcedure 主要用于替换文件中的原有函数,例如 angr 默认会使用一些内置的 SimProcedure 来替换掉一些常见的库函数。在二进制程序中,像 malloc 这样的复杂库函数通常会被自动 hook,以避免路径爆炸和进行符号化分析。

如果我们已经有该二进制文件的符号表,我们可以直接使用 project.hook_symbol(symbol_str, sim_procedure_instance) 来自动 hook 掉文件中所有的对应符号,run() 方法的参数为被替换函数所接收的参数。

1
2
3
4
5
6
7
class MyCheckEquals(angr.SimProcedure):
def run(self, buffer_addr, length):
buffer = self.state.memory.load(buffer_addr, length)
return claripy.If(buffer == b'XCKPBIWXXTQAFOST', claripy.BVV(1, 32), claripy.BVV(0, 32))


proj.hook_symbol(symbol_name='check_equals_XCKPBIWXXTQAFOST', simproc=MyCheckEquals())

SimProcedurerun() 方法中,我们可以使用一些有用的成员函数来控制执行过程,例如:

  • ret(expr) :返回一个表达式值。
  • jump(addr) :跳转到指定的地址。
  • exit(code) :终止程序执行,通常用于模拟程序退出。
  • call(addr, args, continue_at) :调用文件中的一个函数,args 是传递给函数的参数,continue_at 是继续执行的位置。
  • inline_call(procedure, *args) :内联地调用另一个 SimProcedure

这些成员函数使得我们可以更灵活地控制程序的模拟执行,尤其在处理复杂的系统调用、库函数和跳转时非常有用。

符号求解

在完成路径探索之后,如果目标位置可达,则我们可以从仿真管理器的 found 列表中找到执行到目标位置的所有路径对应的状态:

1
2
3
4
5
6
7
8
9
10
11
12
13
# 要求解的内容
bvs_to_solve = claripy.BVS('bvs_to_solve', 64)

# 创建初始状态
state = proj.factory.entry_state()
state.memory.store(0xdeadbeef, bvs_to_solve)

# 路径探索
simgr = proj.factory.simgr(state)
simgr.explore(find = 0xbeefdead)

# 从 found 列表中取出任意一个执行到目标位置的状态
found = simgr.found[0]

state 中实际上内置了符号执行引擎 claripy,前面的路径探索本质上就是为每个 state 内置的符号执行引擎中添加对应的条件。当执行到目标位置时,state 中的符号执行引擎已经添加了能够执行目标位置所需的所有条件。因此我们可以利用符号执行引擎的约束求解功能(state.solver)求解出前面设置的需要求解的内容。

1
found.solver.eval(bvs_to_solve)

如果是标准输入之类的则不需要我们显式的调用约束求解,直接获取即可:

1
simgr.found[0].posix.dumps(0)

符号执行引擎不仅可以通过路径探索添加约束条件,还可以手动添加条件。因此在有些场景下我们不需要完整的执行整个过程,而是只执行前面一部分内容,而后的部分可以手动添加相应的规则。这种策略可以一定程度上避免一些路径爆炸的情况。

1
2
3
found = simgr.found[0]
found.add_constraints(found.memory.load(buffer_addr, 16) == b'XCKPBIWXXTQAFOST')
print(found.solver.eval(password, cast_to=bytes))
  • Title: angr 使用总结
  • Author: sky123
  • Created at : 2025-08-19 00:02:28
  • Updated at : 2025-08-25 01:22:55
  • Link: https://skyi23.github.io/2025/08/19/angr 使用总结/
  • License: This work is licensed under CC BY-NC-SA 4.0.
Comments